Skip to content

Add SSH remote host support for tunneling daemon connections#2396

Open
bendavid wants to merge 7 commits into
getpaseo:mainfrom
bendavid:remotessh
Open

Add SSH remote host support for tunneling daemon connections#2396
bendavid wants to merge 7 commits into
getpaseo:mainfrom
bendavid:remotessh

Conversation

@bendavid

@bendavid bendavid commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Connect to a remote Paseo daemon over SSH from the CLI (--host ssh://user@host) and the desktop app. The CLI ensures a daemon is running on the remote host (installing Paseo if needed), opens an SSH local port-forward, and tunnels the WebSocket connection through it — all over a single SSH connection.

See docs/ssh.md for the design and the gotchas behind it.

How it works

One connection. The remote command is exec /bin/sh while -L forwards the port, and the ensure script is piped to that shell over stdin. sshd hands a remote command to the user's own shell (from the password database), which may be fish or tcsh — so the command is reduced to three metacharacter-free words that every shell agrees on, and the script never has to survive their quoting rules. It also keeps the script out of the remote host's process list.

On success the script deliberately doesn't exit: sh reading from a pipe blocks for the next line, and that block is the keepalive. Closing stdin is the teardown.

Readiness is a READY: marker, not prose. The port forward accepts connections before the remote daemon is listening, so waiting on the local port is a false positive.

Which remote paseo runs, in order: anything already listening on the port → a paseo on PATH (global npm, system package manager) → a Paseo-managed install under installDir. Preferring PATH is about correctness, not disk: a second copy would let someone on the remote host start a conflicting daemon against the same PASEO_HOME. Paseo only ever installs or upgrades inside installDir. The managed copy is pinned to the local CLI version, falling back to latest when that version isn't published (source checkouts, betas).

Password prompts are rendered by Paseo. SSH only takes a secret from a program's stdout, so there is still a small askpass program — but it just relays over a unix socket to the app, which shows a styled, localized prompt that says whether it wants a key passphrase or an account password. The helper is written at runtime, so nothing needs unpacking from an asar archive. This replaced zenity/kdialog/osascript, along with their GTK baggage (underscores read as mnemonic accelerators, Pango markup parsing, AppleScript escaping).

Cancelling ends the attempt. SSH ignores an askpass program's exit status and simply retries, so cancellation is signalled out of band — the desktop aborts an AbortSignal; the CLI's terminal prompt writes a marker to stderr. SSH's three attempts are left intact so a typo stays retryable.

Changes

  • CLI (packages/cli/src/ssh/): ssh-host-config (parse ssh:// URIs), ssh-process (SSH args, tunnel, terminal askpass), ssh-connection (ensure + tunnel), remote-daemon (ensure script and failure messages), askpass-channel (socket relay for GUI prompts)
  • CLI (local-daemon.ts): systemd-run --user --scope + loginctl enable-linger so a detached daemon survives the session that started it, behind a DaemonLaunchRuntime seam
  • Protocol: SshHostConnectionSchema. port is deliberately not defaulted — "unset" has to stay distinguishable from "22" so a Port directive in ~/.ssh/config wins
  • App: SSH connection type end to end, add-host modal, and a globally mounted password prompt so reconnects outside the add-host flow are covered
  • Desktop (main.ts, preload.ts): SSH bridge IPC — open/close tunnel, progress events, password relay

Notes for review

  • Origin stripping is scoped. A renderer's origin can never match a tunnel's ephemeral loopback port, so the daemon's same-origin check would reject every tunneled connection. The header is removed only for ports the main process is currently forwarding — never for loopback generally, which would also disable the check for a directly-connected local daemon. Untrusted web content runs in a separate Electron session the rule isn't installed on.
  • Host keys are trust-on-first-use (StrictHostKeyChecking=accept-new). A changed key still fails, but a first connection from the GUI accepts an unknown key without showing a fingerprint. Noted in SECURITY.md.
  • Non-active SSH connections are not latency-probed. Probing one means spawning ssh, authenticating, and running the ensure script — every 120s, re-prompting password-auth hosts. The tradeoff is that the adaptive switcher won't move onto a tunnel on RTT alone, which is the behavior we want anyway. Failover is unaffected.
  • Requires a POSIX remote with /bin/sh and Node. The local GUI prompt path is POSIX-only too (Windows would need a named pipe).

Test plan

  • paseo ls -a --host ssh://submit82.mit.edu — single password prompt, agents listed, clean exit
  • Fresh daemon start on remote host — ensure script installs/launches, tunnel connects
  • Desktop SSH connection — progress messages displayed, WebSocket connects through tunnel
  • Askpass channel driven exactly as ssh drives it (prompt as argv[1], secret on stdout): relay, decline, timeout, closed-channel
  • Cancel vs. wrong password measured against a live sshd — declining aborts on the first prompt (~120ms); a wrong password still retries
  • Ensure script executed through a real sh against stubbed PATHs — ready/node-missing/npm-missing/PATH-preferred/unpublished-version paths
  • typecheck, lint, format, and 186 unit tests across cli/app/protocol/desktop

bendavid added 5 commits July 24, 2026 16:57
Connect to a remote Paseo daemon over SSH from the CLI (--host
ssh://user@host:port) and the desktop app. The CLI ensures a daemon
is running on the remote host (installing Paseo if needed), opens an
SSH local port-forward, and tunnels the WebSocket connection through it.

CLI:
- ssh-host-config: parse ssh:// URIs, normalize host configs
- ssh-process: SSH exec, tunnel (SshTunnel), askpass scripts
- ssh-connection: connectViaSsh, ControlMaster connection sharing
- remote-daemon: ensure script (check/install/launch/wait in one SSH call)
- local-daemon: systemd-run --user --scope + loginctl enable-linger for
  cgroup escape so the daemon survives SSH disconnect
- client.ts: --host ssh:// support, PTY for interactive password prompts

Protocol:
- SshHostConnectionSchema with host, port, user, remotePort, remoteHome,
  installDir fields

App:
- AddSshHostModal: add SSH host from the desktop UI
- host-runtime: probeAndUpsertSshConnection, SSH connection handling
- test-daemon-connection: connectToDaemonViaSsh (ensure + tunnel + probe)
- host-connection types: SSH connection parsing and equality

Desktop:
- preload.ts: SSH bridge IPC (ensureRemoteDaemon, openTunnel, closeTunnel)
The Electron renderer's Origin header (http://localhost:8083 from Metro)
didn't match the SSH tunnel port (e.g. 127.0.0.1:35173). The remote
daemon's same-origin check rejected the port mismatch, causing the
WebSocket to close with code 1006.

Desktop: use session.webRequest.onBeforeSendHeaders to strip the Origin
header for WebSocket upgrades to ws://127.0.0.1:* and ws://localhost:*.
The SSH tunnel itself is the trust boundary, so no origin check is needed
for loopback connections.
Instead of two separate SSH connections (ensure + tunnel) with
ControlMaster multiplexing, open a single SSH connection that runs the
ensure script inline and keeps the port forward alive with 'exec sleep
infinity' after the script exits.

- SshTunnel.open accepts an ensureScript option: runs it on the remote
  host through the same SSH connection, then keeps alive with sleep
- Readiness is detected via PROGRESS: messages from the ensure script
  (not waitForLocalPort, which gives false positives when the forward
  is up but the daemon isn't listening yet)
- Removed ControlMaster, sshControlPath, and the separate
  ensureRemoteDaemon IPC handler from the desktop
- The desktop's openTunnel handler now takes the ensure script and
  runs everything in one connection
- CLI uses a terminal askpass (read -rs /dev/tty) for password prompts
  since the SSH stderr is piped for progress parsing
- Ensure script wrapped in a function (return instead of exit) so the
  shell survives after the script completes
The desktop main process emits paseo:event:ssh-progress events with
progress messages from the ensure script. The app subscribes to these
events in connectToDaemonViaSsh and forwards them through the
onProgress callback chain to the AddSshHostModal, which displays them
below the form while connecting.

- Desktop: emit ssh-progress events from openTunnel IPC handler
- App: onProgress in ProbeOptions, connectToDaemon deps, and
  probeAndUpsertSshConnection
- AddSshHostModal: progressMessage state and display
- host-runtime.ts: add installDir to probeAndUpsertSshConnection input
  type (was used but not declared, causing typecheck failure)
- local-daemon.ts: remove module-level cache from isSystemdRunUserAvailable,
  make it a pure function; move loginctl enable-linger into
  startLocalDaemonDetached; add forceNoSystemdRun option to bypass
  systemd-run detection in tests
- local-daemon.supervision.test.ts: pass forceNoSystemdRun: true instead
  of manipulating XDG_RUNTIME_DIR env
@bendavid
bendavid force-pushed the remotessh branch 2 times, most recently from de6f20e to d5bd61e Compare July 24, 2026 17:48
@bendavid
bendavid marked this pull request as draft July 26, 2026 12:26
@bendavid
bendavid marked this pull request as ready for review July 26, 2026 18:13
Addresses review of the SSH tunnel feature. The largest change is
replacing the system password dialog with one Paseo renders itself.

Askpass over a socket
- Add `askpass-channel`: a unix socket plus a generated shell wrapper and
  Node helper, so SSH's prompts are answered by Paseo's own UI instead of
  `zenity`/`kdialog`/`osascript`. The helper is written at runtime, so
  there is no packaged asset to resolve inside an asar archive.
- Drop the GTK-specific workarounds that dialog needed: underscores read
  as mnemonic accelerators, Pango markup parsing, AppleScript escaping.
- Distinguish a key passphrase from an account password, and render the
  prompt in a globally mounted modal so reconnects outside the add-host
  flow are covered too. Prompts queue rather than replace each other.
- Cancelling now ends the attempt. SSH ignores an askpass program's exit
  status and just retries, so cancellation is signalled out of band: the
  desktop aborts an `AbortSignal`, the CLI's terminal prompt writes a
  marker to stderr. SSH's three attempts are left intact so a typo stays
  retryable.

Remote ensure script
- Prefer a `paseo` already on `PATH` over installing a second copy, which
  would let a user on the remote host start a conflicting daemon against
  the same PASEO_HOME. Paseo only installs or upgrades inside installDir.
- Pin the managed install to the local CLI version, falling back to
  `latest` when that version is not published (source checkouts, betas).
- Pipe the script to `exec /bin/sh` over stdin rather than passing it as
  the remote command: sshd hands a remote command to the user's own
  shell, which may be fish or tcsh. The keepalive is now the absence of
  an exit, which also removes the non-portable `sleep infinity`.
- Preserve exit codes and shell-quote every interpolated value.

Correctness
- Restore a `return` dropped from `switchToConnection`, which let a
  superseded switch tear down the connection that superseded it.
- Route every client disposal through `closeClientAndTransport` so
  abandoned probes and aborted switches cannot orphan an ssh process.
- Stop latency-probing non-active SSH connections: each probe spawned
  ssh, authenticated, and ran the ensure script, then threw the tunnel
  away, re-prompting password-auth hosts every cycle.
- Leave `port` unset when unspecified so `~/.ssh/config` wins, and stop
  rendering `@host:undefined` when user and port are both absent.
- Scope the desktop Origin strip to ports the main process forwards, and
  install the rule once per session rather than per window.
- Surface SSH's stderr and exit code on failure; both were being lost.
- Move systemd scope detection behind `DaemonLaunchRuntime` instead of a
  test-only flag on the production options type.

UI
- Localize the SSH flow across all locales; gate it on `getIsElectron()`
  rather than `isWeb`; make the connect cancellable; mark Port optional;
  submit on Enter from any field.

Docs
- Add docs/ssh.md covering the single-connection tunnel, the ensure
  script contract, which remote `paseo` runs, and the askpass and Origin
  gotchas. Note the SSH trust boundary in SECURITY.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant